Skip to content

refactor(membership): deduplicate repeated patterns - #1827

Merged
whoAbhishekSah merged 4 commits into
mainfrom
refactor/membership-dedupe
Jul 31, 2026
Merged

refactor(membership): deduplicate repeated patterns#1827
whoAbhishekSah merged 4 commits into
mainfrom
refactor/membership-dedupe

Conversation

@whoAbhishekSah

@whoAbhishekSah whoAbhishekSah commented Jul 30, 2026

Copy link
Copy Markdown
Member

What this PR does

It removes duplicated code inside the core/membership package. It builds on #1826, which split the one big service file into smaller files. With the code side by side, the copies were easy to see.

No results change. Every public function keeps its name and signature. The package shrinks by 165 lines. One function makes one less database query (change #9, from review).

The duplications, one by one

1. Two functions built the same policy filter.
resourcePolicyFilter (list.go) and policyFilterForResource (service.go) did the same job: take a resource ID and type, and set the matching field on policy.Filter (OrgID, ProjectID, or GroupID). Now only resourcePolicyFilter exists. removeAllPolicies uses it, and then calls the existing removePoliciesByFilter instead of keeping its own copy of the list-then-delete loop.

One side effect to know about: the filter built by removeAllPolicies now also sets ResourceType. The query result is the same. The repository already pins the resource type whenever OrgID/ProjectID/GroupID is set (see applyListFilter in internal/store/postgres/policy_repository.go), so the new condition is redundant, not different.

2. Three role validators were the same function.
validateOrgRole, validateProjectRole, and validateGroupRole all did the same three steps: fetch the role, check it is scoped to the right namespace, then accept it if it is a platform-wide role or a custom role of this org. Only the namespace and the returned error differed. The shared steps now live in validateRoleForScope. The three old functions stay as one-line wrappers, so call sites read the same as before.

3. Two "don't remove the last owner" checks were the same function.
validateMinOwnerConstraint (org) and validateMinGroupOwnerConstraint (group) ran the same algorithm: fetch the owner role, skip the check if the member is being promoted to owner or isn't an owner, otherwise count the owner policies and fail if this member is the only one. Both now call the shared validateMinRoleConstraint. Each wrapper passes its own owner role name, filter, and error.

4. Five audit helpers repeated the same ~30 lines.
The org and group helpers for member added, role changed, and member removed each built the same audit record (target, metadata with role_id and email) and then wrote the same legacy audit log. That shared body is now one function, auditMemberChange. The five helpers became short wrappers that only supply the event names and attributes. Adding the next membership event is a five-line wrapper.

5. A repeated condition got a name.
len(existing) == 1 && existing[0].RoleID == roleID appeared at four call sites. It now reads hasExactlyRole(existing, roleID), which says what the check means: the member already has exactly this role, so there is nothing to change.

6. String-glued map keys became a struct.
Two places built map keys by joining strings: principalType + "\x00" + principalID. They now use a small struct, principalKey{Type, ID}. Go structs work directly as map keys, and there is no separator character to reason about.

7. The longest function got split.
ListPrincipalsByResource was ~85 lines doing two jobs: find the members, then fetch every role each member holds. The second job moved into its own function, enrichMemberRoles.

8. Hand-written loops replaced by an existing helper.
listOrgsForPrincipal and listGroupsForPrincipal each looped over policies to collect resource IDs and dedupe them. The helper policyResourceIDs already did exactly that, so they call it now.

9. One less query in ListPrincipalsByResource (from review).
CodeRabbit spotted a pre-existing inefficiency that the split made visible: when the caller passes no role filter, the role-enrichment query was identical to the member query, so the function hit the database twice for the same rows. Now the first result is reused; only a role-filtered listing issues the second, unfiltered query. Same results, one query instead of two on the common path.

What I looked at but did not merge

validateOrgMembership and validatePrincipal overlap, but they return different errors (ErrNotOrgMember vs ErrPrincipalNotInOrg), and only one of them checks whether a service user is disabled. Merging them would change behavior, and this PR promises not to. Left as is.

Tests

Two kinds of test changes, both matching the changes above:

Everything else passes untouched. go test ./core/... ./internal/api/... is green.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
frontier Ready Ready Preview Jul 31, 2026 9:43am

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c6fdc206-193d-4536-a5a9-c9f614c2a668

📥 Commits

Reviewing files that changed from the base of the PR and between f60c879 and 423e594.

📒 Files selected for processing (2)
  • core/membership/group.go
  • core/membership/service.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • core/membership/service.go

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes
    • Improved membership role updates to avoid unnecessary changes when the requested role is already assigned.
    • Strengthened validation for organization, group, and project roles, including minimum-owner and minimum-role protections.
    • Improved membership removal and policy cleanup consistency.
    • Enhanced principal listing and role enrichment accuracy, including reliable deduplication.
    • Standardized membership audit records and improved handling of legacy audit logging.
    • Reduced redundant membership policy lookups for more efficient listing operations.

Walkthrough

The membership service centralizes policy filtering, role validation, typed principal handling, role enrichment, policy cleanup, and audit writes across organization, group, and project membership operations.

Changes

Membership Refactor

Layer / File(s) Summary
Shared policy and role helpers
core/membership/service.go
Adds resource-scoped filters, exact-role checks, role-scope validation, minimum-role constraints, and policy cleanup helpers.
Typed principal listing and role enrichment
core/membership/list.go, core/membership/list_test.go
Uses typed principal keys, shared filters, role enrichment, resource ID deduplication, and updated policy-list expectations.
Organization, group, and project role integration
core/membership/org.go, core/membership/group.go, core/membership/project.go, core/membership/project_test.go
Uses shared idempotency and validation helpers, typed keys for group cleanup, and resource-typed project policy filters.
Centralized membership audit writes
core/membership/audit.go
Centralizes audit target construction, audit-record creation, and legacy audit logging.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers: rohilsurana

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 30536703296

Coverage decreased (-0.1%) to 46.99%

Details

  • Coverage decreased (-0.1%) from the base build.
  • Patch coverage: 12 uncovered changes across 3 files (146 of 158 lines covered, 92.41%).
  • 1 coverage regression across 1 file.

Uncovered Changes

File Changed Covered %
core/membership/service.go 53 47 88.68%
core/membership/list.go 44 39 88.64%
core/membership/audit.go 49 48 97.96%
Total (6 files) 158 146 92.41%

Coverage Regressions

1 previously-covered line in 1 file lost coverage.

File Lines Losing Coverage Coverage
core/membership/audit.go 1 93.65%

Coverage Stats

Coverage Status
Relevant Lines: 38821
Covered Lines: 18242
Line Coverage: 46.99%
Coverage Strength: 14.58 hits per line

💛 - Coveralls

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
core/membership/list.go (1)

56-152: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid a redundant duplicate policy list when no role filter is applied.

When filter.RoleIDs is empty, flt passed into enrichMemberRoles (line 85) is identical to the filter already used at line 64 to fetch policies, yet enrichMemberRoles unconditionally re-queries s.policyService.List with that same filter (line 99). This doubles the DB/service round trip on the common "list all members, no role filter" path.

Consider reusing the already-fetched policies for enrichment when filter.RoleIDs is empty, only re-listing when a role filter actually narrowed the initial selection.

♻️ Sketch of the optimization
 func (s *Service) ListPrincipalsByResource(ctx context.Context, resourceID, resourceType string, filter MemberFilter) ([]Member, error) {
 	flt, err := resourcePolicyFilter(resourceID, resourceType)
 	if err != nil {
 		return nil, err
 	}
 	flt.PrincipalType = filter.PrincipalType
 	flt.RoleIDs = filter.RoleIDs
 
 	policies, err := s.policyService.List(ctx, flt)
 	if err != nil {
 		return nil, fmt.Errorf("list policies: %w", err)
 	}
 	policies = excludePATAllProjects(policies, resourceType)
 	...
-	if err := s.enrichMemberRoles(ctx, flt, resourceType, memberIndex, members); err != nil {
+	var reusable []policy.Policy
+	if len(filter.RoleIDs) == 0 {
+		reusable = policies // already the full unfiltered set
+	}
+	if err := s.enrichMemberRoles(ctx, flt, resourceType, memberIndex, members, reusable); err != nil {
 		return nil, err
 	}
 	return members, nil
 }

 func (s *Service) enrichMemberRoles(ctx context.Context, flt policy.Filter, resourceType string, memberIndex map[principalKey]int, members []Member, allPolicies []policy.Policy) error {
-	flt.RoleIDs = nil
-	allPolicies, err := s.policyService.List(ctx, flt)
-	if err != nil {
-		return fmt.Errorf("list policies for role enrichment: %w", err)
+	if allPolicies == nil {
+		flt.RoleIDs = nil
+		var err error
+		allPolicies, err = s.policyService.List(ctx, flt)
+		if err != nil {
+			return fmt.Errorf("list policies for role enrichment: %w", err)
+		}
+		allPolicies = excludePATAllProjects(allPolicies, resourceType)
 	}
-	allPolicies = excludePATAllProjects(allPolicies, resourceType)
 	...

Note: existing unit tests pinned to two identical List calls (e.g. core/membership/list_test.go) would need updating alongside this change.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d895a0ae-fc74-436b-9c66-c58eaaaa0a8c

📥 Commits

Reviewing files that changed from the base of the PR and between 98a7873 and 42a6da4.

📒 Files selected for processing (7)
  • core/membership/audit.go
  • core/membership/group.go
  • core/membership/list.go
  • core/membership/org.go
  • core/membership/project.go
  • core/membership/project_test.go
  • core/membership/service.go

@whoAbhishekSah

Copy link
Copy Markdown
Member Author

Applied the CodeRabbit suggestion in c3464d7: ListPrincipalsByResource now reuses the first policy query for role enrichment when no role filter is set; only a role-filtered listing issues the second, unfiltered query. The four no-role-filter test cases pin the single call with .Once().

@whoAbhishekSah

Copy link
Copy Markdown
Member Author

Tested every code path this PR touches, on a live server. No behavior changes found.

Setup

  • Built this branch and ran it on a local sandbox (port 8003), pointed at the same local Postgres and SpiceDB as a second server running the code before this PR — so every read could be compared between the two.
  • Fresh org with 4 new users: alice (creator/owner), bob, charlie, dave. A project and two groups under the org.
  • A second org owned by dave, with a custom role in it, for the cross-org role test.
  • Admin RPCs used the bootstrap service account. User RPCs used normal login sessions.
  • Audit checks read the audit_records and auditlogs tables directly.
  • For the review follow-up (duplicate query fix), counted the actual policies queries per request using Postgres statement logging, comparing the commit before the fix against the fix.
# Area Test Expected Result
1 Org members Add bob with the org viewer role Added, audit written
2 Org members Add with a project-scoped role Rejected: "role is not valid for organization scope"
3 Org members Add with another org's custom role Rejected, same error
4 Org members Set the same role again Nothing changes, no duplicate role
5 Org members Change bob viewer → manager Role replaced, audit written
6 Org members Demote an owner while another owner exists Allowed
7 Org members Demote the only remaining owner Blocked: "org must have at least one owner..."
8 Org lists ListOrganizationUsers, no filter All members with their full role lists, same as before
9 Org lists ListOrganizationUsers, filtered by role Only matching members, but each still shows all their roles
10 Org lists ListOrganizationAdmins Returns the owner
11 Org lists ListOrganizationsByCurrentUser as bob Shows his org, same as before
12 Org lists ListOrganizationsByUser As a normal user: blocked (superuser-only, by design). As superuser: returns the orgs
13 Project members Add bob as project viewer Added
14 Project members Set the same role again Nothing changes
15 Project members Add with an org-scoped role Rejected: "role is not valid for project scope"
16 Project members ListProjectUsers with bob holding 2 roles Bob listed once, both roles shown
17 Project members Remove bob, who had 2 policies on the project Both policies deleted (0 rows left in DB); his org membership untouched
18 Group members Add bob as group member Added, audit written
19 Group members Set the same role again Nothing changes
20 Group members Add with a project-scoped role Rejected: "role is not valid for group scope"
21 Group members Promote bob member → group owner Works, audit written
22 Group members Demote a group owner while another owner exists Allowed
23 Group members Demote the last group owner Blocked: "group must have at least one owner..."
24 Group members Remove a member Removed; audit written with email and no role_id (same as before)
25 Group lists ListGroupUsers / ListCurrentUserGroups Correct members and groups
26 Group delete Delete a group with 3 members Every member policy on the group deleted (0 rows left)
27 Audit All added / role_changed / removed events in both audit tables Same events, email, role_id and group_id fields as before
28 Review fix Policy queries per list call, no role filter 2 queries before the fix → 1 after; results identical
29 Review fix Policy queries per list call, with a role filter 2 queries in both versions (the second one is needed for full role lists)

Unit tests pass (go test ./core/membership/...), and the mocks now pin the list call to run once on the unfiltered path, so a regression back to double-querying would fail CI.

whoAbhishekSah and others added 2 commits July 31, 2026 12:42
Behavior unchanged; call sites and public signatures stay the same.

- One resourcePolicyFilter builds resource-scoped policy filters;
  the duplicate policyFilterForResource is gone. removeAllPolicies now
  goes through it and reuses removePoliciesByFilter, so its filter also
  pins resource_type (redundant with the per-type ID fields in SQL).
- validateOrgRole/validateProjectRole/validateGroupRole are one-line
  wrappers over a shared validateRoleForScope.
- validateMinOwnerConstraint/validateMinGroupOwnerConstraint are
  wrappers over a shared validateMinRoleConstraint.
- The five near-identical org/group audit helpers delegate to one
  auditMemberChange that writes both audit stores.
- hasExactlyRole names the repeated "already has exactly this role"
  check.
- principalKey struct replaces the \x00-joined string map keys, and
  ListPrincipalsByResource's role enrichment moves into its own
  function.
- listOrgsForPrincipal/listGroupsForPrincipal reuse policyResourceIDs
  instead of hand-rolled pluck loops.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… is set

Review follow-up (CodeRabbit): with no role filter, the enrichment query
in ListPrincipalsByResource was identical to the member query, costing a
second round trip on the common path. Reuse the first result; only a
role-filtered listing issues the second, unfiltered query.
enrichMemberRoles now takes the policy set instead of fetching it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread core/membership/service.go Outdated
Comment thread core/membership/service.go Outdated
Comment thread core/membership/group.go Outdated
- validateMinRoleConstraint wraps its errors with the guard role name,
  so org and group owner lookups stay distinguishable in logs
- RemoveAllGroupMembers tracks principals in a set; the relation pass
  reads the identity from the key instead of an arbitrary policy

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@whoAbhishekSah
whoAbhishekSah merged commit 8cfe082 into main Jul 31, 2026
8 checks passed
@whoAbhishekSah
whoAbhishekSah deleted the refactor/membership-dedupe branch July 31, 2026 09:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants